/*
 *  Copyright (c) 2013 Hai Bison
 *
 *  See the file LICENSE at the root directory of this project for copying
 *  permission.
 */

package com.haibison.android.hutieu;

import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;

import android.content.Context;
import android.content.res.Resources;
import android.net.Uri;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;

import com.haibison.android.hutieu.utils.Settings;
import com.haibison.android.hutieu.utils.SimpleWeakEncryption;

/**
 * This server should be used to handle requests for <b>compressed</b> resources
 * only. Although it can handle all type of resources, but it spends more system
 * resources (RAM, CPU...)
 * <p/>
 * <h1>Usage</h1>
 * <p/>
 * <ol>
 * <li>Creates new instance of this server.</li>
 * <li>When you create new client socket, before connecting to this server,
 * encrypt the resource URI with {@link SimpleWeakEncryption},
 * {@link #getPassword()} and {@link #getSalt()}.</li>
 * <li>Connect to this server and send the encrypted URI. Note that you must
 * send a zero byte at the end, to mark it "ended".</li>
 * <li>You're done. The data returned will mostly be handled by the one who
 * originally queried the URI.</li>
 * </ol>
 * 
 * @author Hai Bison
 * 
 */
public class ShareProviderServer extends ServerSocket {

    private static final String CLASSNAME = ShareProviderServer.class.getName();

    /**
     * Maximum length for reading the handshake message from clients.
     */
    private static final int MSG_LENGTH = 4096;
    /**
     * Delay time while waiting for clients, in milliseconds.
     */
    private static final int DELAY_TIME = 99;

    private static final int PASSWORD_SIZE = 32;
    private static final int SALT_SIZE = 512;

    /**
     * Other apps have 1 minute to handle opened connection :-)
     */
    private static final int CLIENT_WAITING_TIMEOUT = (int) DateUtils.MINUTE_IN_MILLIS;

    private final Context mContext;
    private final Listener mListener;
    private final char[] mPassword = new BigInteger(
            SecureRandom.getSeed(PASSWORD_SIZE)).toString(Character.MAX_RADIX)
            .toCharArray();
    private final byte[] mSalt = SecureRandom.getSeed(SALT_SIZE);
    private int mMaxConcurrentThreads;
    private int mMaxIdleTime;

    /**
     * Creates new instance which will immediately try to bind to a local port.
     * The port is automatically picked by the system. If binding successfully,
     * it automatically listens for clients.
     * 
     * @param context
     *            the context.
     * @param maxConcurrentThreads
     *            the max concurrent threads allowed.
     * @param maxIdleTime
     *            the max idle time allowed.
     * @throws IOException
     *             if any.
     */
    public ShareProviderServer(Context context, int maxConcurrentThreads,
            int maxIdleTime) throws IOException {
        /*
         * Let the system pick us a port.
         */
        super(0);

        mContext = context;
        mMaxConcurrentThreads = maxConcurrentThreads;
        mMaxIdleTime = maxIdleTime;
        mListener = new Listener();

        mListener.start();

        if (BuildConfig.DEBUG)
            Log.d(CLASSNAME, "STARTED");
    }// ShareProviderServer()

    @Override
    public void close() throws IOException {
        if (BuildConfig.DEBUG)
            Log.d(CLASSNAME, "STOPPING...");

        mListener.interrupt();
        super.close();
    }// close()

    /**
     * Sets max concurrent threads allowed.
     * 
     * @param n
     *            the max concurrent threads allowed.
     * @return the instance of this object, for chaining multiple calls into a
     *         single statement.
     */
    public ShareProviderServer setMaxConcurrentThreads(int n) {
        mMaxConcurrentThreads = n;
        return this;
    }// setMaxConcurrentThreads()

    /**
     * Gets max concurrent threads allowed.
     * 
     * @return the max concurrent threads allowed.
     */
    public int getMaxConcurrentThreads() {
        return mMaxConcurrentThreads;
    }// getMaxConcurrentThreads()

    /**
     * Sets max idle time.
     * 
     * @param millis
     *            the max idle time.
     * @return the instance of this object, for chaining multiple calls into a
     *         single statement.
     */
    public ShareProviderServer setMaxIdleTime(int millis) {
        mMaxIdleTime = millis;
        return this;
    }// setMaxIdleTime()

    /**
     * Gets max idle time, in milliseconds.
     * 
     * @return the max idle time.
     */
    public int getMaxIdleTime() {
        return mMaxIdleTime;
    }// getMaxIdleTime()

    /**
     * Gets the unique password which belongs to current instance of this
     * server.
     * 
     * @return the unique password which belongs to current instance of this
     *         server.
     */
    public char[] getPassword() {
        return mPassword;
    }// getPassword()

    /**
     * Gets the unique salt of this instance.
     * 
     * @return the unique salt of this instance.
     */
    public byte[] getSalt() {
        return mSalt;
    }// getSalt()

    /**
     * This thread listens for clients.
     * 
     * @author Hai Bison
     * 
     */
    private class Listener extends Thread {

        final List<ClientHandler> mClients = new ArrayList<ClientHandler>();

        @Override
        public void run() {
            while (!isInterrupted() && isBound()) {
                if (mClients.size() < getMaxConcurrentThreads()) {
                    try {
                        setSoTimeout(getMaxIdleTime());
                        ClientHandler client = new ClientHandler(accept()) {

                            @Override
                            public void onFinished(ClientHandler self) {
                                mClients.remove(self);
                            }// onFinished()

                        };
                        mClients.add(client);
                        client.start();

                        if (BuildConfig.DEBUG)
                            Log.d(CLASSNAME, "just accepted new client...");
                    } catch (InterruptedIOException e) {
                        if (mClients.isEmpty()) {
                            Log.v(CLASSNAME,
                                    "Reach max idle time, stopping server...");
                            try {
                                close();
                            } catch (IOException e1) {
                                Log.e(CLASSNAME, e1.getMessage(), e1);
                            }
                            return;
                        }
                    } catch (IOException e) {
                        Log.e(CLASSNAME, e.getMessage(), e);
                    }
                } else {
                    try {
                        sleep(DELAY_TIME);
                    } catch (InterruptedException e) {
                        Log.e(CLASSNAME, e.getMessage(), e);
                        interrupt();
                    }
                }
            }// while
        }// run()

        @Override
        public void interrupt() {
            synchronized (mClients) {
                for (ClientHandler client : mClients)
                    client.interrupt();
            }// synchronized

            super.interrupt();
        }// interrupt()

    }// Listener

    /**
     * This thread handles a single client.
     * 
     * @author Hai Bison
     * 
     */
    private abstract class ClientHandler extends Thread {

        final Socket mClient;

        /**
         * Creates new instance.
         * 
         * @param client
         *            the client.
         */
        public ClientHandler(Socket client) {
            mClient = client;
        }// ClientHandler()

        @Override
        public void run() {
            try {
                Uri uri = decryptUri();
                if (uri != null)
                    handleUri(uri);
            } finally {
                try {
                    mClient.close();
                } catch (IOException e) {
                    Log.e(CLASSNAME, e.getMessage(), e);
                }
                onFinished(this);
            }
        }// run()

        @Override
        public void interrupt() {
            try {
                mClient.close();
            } catch (IOException e) {
                Log.e(CLASSNAME, e.getMessage(), e);
            }

            super.interrupt();
        }// interrupt()

        /**
         * Decrypts incoming URI from client.
         * 
         * @return the decrypted URI, or {@code null} if any error occurred.
         */
        private Uri decryptUri() {
            try {
                mClient.setSoTimeout(CLIENT_WAITING_TIMEOUT);
                StringBuilder sb = new StringBuilder();

                /*
                 * NOTE: DON'T close this input stream or you'll close the
                 * connection.
                 */
                InputStream inputStream = mClient.getInputStream();

                int read = 0, totalRead = 0;
                byte[] buf = new byte[Settings.FILE_BUFFER];
                while ((read = inputStream.read(buf)) > 0) {
                    totalRead += read;
                    if (isInterrupted() || totalRead > MSG_LENGTH)
                        return null;

                    sb.append(new String(buf, 0, buf[read - 1] == 0 ? read - 1
                            : read));
                    if (buf[read - 1] == 0)
                        break;
                }// while

                try {
                    /*
                     * Supposed that only clients generated by our app can
                     * provide a correct encrypted data -- which can be
                     * decrypted successfully. So we don't need to validate the
                     * URI after decrypting it.
                     */
                    return Uri.parse(SimpleWeakEncryption.decrypt(
                            getPassword(), getSalt(), sb.toString()));
                } catch (Throwable t) {
                    return null;
                }
            } catch (IOException e) {
                return null;
            }
        }// decryptUri()

        /**
         * Handles resource URI.
         * 
         * @param uri
         *            the resource URI.
         */
        private void handleUri(Uri uri) {
            if (BuildConfig.DEBUG)
                Log.d(CLASSNAME, "handleUri() >> " + uri);

            try {
                switch (ShareProvider.getUriMatcher(mContext).match(uri)) {
                case ShareProvider.URI_CONTENT_ITEM_ID: {
                    /*
                     * The URI matcher guarantees the `parseInt()`.
                     */
                    sendDataAndCloseWhenDone(mContext.getResources()
                            .openRawResource(
                                    Integer.parseInt(uri.getLastPathSegment())));
                    break;
                }// URI_CONTENT_ITEM_ID

                case ShareProvider.URI_CONTENT_ITEM_ASSET: {
                    /*
                     * The URI matcher makes sure the last path segment is not
                     * null.
                     */
                    sendDataAndCloseWhenDone(mContext.getAssets().open(
                            uri.getLastPathSegment()));
                    break;
                }// URI_CONTENT_ITEM_ASSET

                case ShareProvider.URI_CONTENT_ITEM_NAME: {
                    String assetPath = ShareProviderUtils.getQueryParameter(
                            uri, ShareContract.PARAM_RES_ASSET);
                    if (TextUtils.isEmpty(assetPath)) {
                        String id = ShareProviderUtils.getQueryParameter(uri,
                                ShareContract.PARAM_RES_ID);
                        if (!TextUtils.isEmpty(id)) {
                            /*
                             * We catch the `parseInt()` below.
                             */
                            sendDataAndCloseWhenDone(mContext.getResources()
                                    .openRawResource((Integer.parseInt(id))));
                        }
                    } else {
                        sendDataAndCloseWhenDone(mContext.getAssets().open(
                                uri.getLastPathSegment()));
                    }

                    break;
                }// URI_CONTENT_ITEM_NAME
                }
            } catch (NumberFormatException e) {
                Log.e(CLASSNAME, e.getMessage(), e);
            } catch (IOException e) {
                Log.e(CLASSNAME, e.getMessage(), e);
            } catch (Resources.NotFoundException e) {
                Log.e(CLASSNAME, e.getMessage(), e);
            } catch (Throwable t) {
                Log.e(CLASSNAME, t.getMessage(), t);
            }
        }// handleUri()

        /**
         * Sends data of given resource's input stream to client. Also closes
         * the source input stream when done.
         * 
         * @param inputStream
         *            the input stream of some resource.
         * @throws IOException
         *             if any.
         */
        private void sendDataAndCloseWhenDone(InputStream inputStream)
                throws IOException {
            if (BuildConfig.DEBUG)
                Log.d(CLASSNAME, "sendDataAndCloseWhenDone()");

            try {
                OutputStream outputStream = mClient.getOutputStream();
                try {
                    int read = 0;
                    byte[] buf = new byte[Settings.FILE_BUFFER];
                    while ((read = inputStream.read(buf)) > 0
                            && !isInterrupted() && !mClient.isOutputShutdown())
                        outputStream.write(buf, 0, read);
                    outputStream.flush();

                    if (BuildConfig.DEBUG)
                        Log.d(CLASSNAME, "\t DONE");
                } finally {
                    if (outputStream != null)
                        outputStream.close();
                }
            } finally {
                inputStream.close();
            }
        }// sendDataAndCloseWhenDone()

        /**
         * Will be called when this thread finishes its job.
         * 
         * @param self
         *            the instance of this object.
         */
        public abstract void onFinished(ClientHandler self);

    }// ClientHandler

}
